Pārlūkot izejas kodu

Add support for machine heartbeat (#1541)

* add the last_heartbeat field

* add heartbeat controller

* add endpoint of heartbeat

* heartbeat integration

* add last_heartbeat to cscli machines list
Thibault "bui" Koechlin 3 gadi atpakaļ
vecāks
revīzija
fe09737d80

+ 10 - 5
cmd/crowdsec-cli/machines.go

@@ -134,7 +134,7 @@ Note: This command requires database direct access, so is intended to be run on
 		Run: func(cmd *cobra.Command, args []string) {
 			machines, err := dbClient.ListMachines()
 			if err != nil {
-				log.Errorf("unable to list blockers: %s", err)
+				log.Errorf("unable to list machines: %s", err)
 			}
 			if csConfig.Cscli.Output == "human" {
 				table := tablewriter.NewWriter(os.Stdout)
@@ -143,7 +143,7 @@ Note: This command requires database direct access, so is intended to be run on
 
 				table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
 				table.SetAlignment(tablewriter.ALIGN_LEFT)
-				table.SetHeader([]string{"Name", "IP Address", "Last Update", "Status", "Version"})
+				table.SetHeader([]string{"Name", "IP Address", "Last Update", "Status", "Version", "Last Heartbeat"})
 				for _, w := range machines {
 					var validated string
 					if w.IsValidated {
@@ -151,7 +151,12 @@ Note: This command requires database direct access, so is intended to be run on
 					} else {
 						validated = emoji.Prohibited.String()
 					}
-					table.Append([]string{w.MachineId, w.IpAddress, w.UpdatedAt.Format(time.RFC3339), validated, w.Version})
+					lastHeartBeat := time.Now().UTC().Sub(*w.LastHeartbeat)
+					hbDisplay := lastHeartBeat.Truncate(time.Second).String()
+					if lastHeartBeat > 2*time.Minute {
+						hbDisplay = fmt.Sprintf("%s %s", emoji.Warning.String(), lastHeartBeat.Truncate(time.Second).String())
+					}
+					table.Append([]string{w.MachineId, w.IpAddress, w.UpdatedAt.Format(time.RFC3339), validated, w.Version, hbDisplay})
 				}
 				table.Render()
 			} else if csConfig.Cscli.Output == "json" {
@@ -162,7 +167,7 @@ Note: This command requires database direct access, so is intended to be run on
 				fmt.Printf("%s", string(x))
 			} else if csConfig.Cscli.Output == "raw" {
 				csvwriter := csv.NewWriter(os.Stdout)
-				err := csvwriter.Write([]string{"machine_id", "ip_address", "updated_at", "validated", "version"})
+				err := csvwriter.Write([]string{"machine_id", "ip_address", "updated_at", "validated", "version", "last_heartbeat"})
 				if err != nil {
 					log.Fatalf("failed to write header: %s", err)
 				}
@@ -173,7 +178,7 @@ Note: This command requires database direct access, so is intended to be run on
 					} else {
 						validated = "false"
 					}
-					err := csvwriter.Write([]string{w.MachineId, w.IpAddress, w.UpdatedAt.Format(time.RFC3339), validated, w.Version})
+					err := csvwriter.Write([]string{w.MachineId, w.IpAddress, w.UpdatedAt.Format(time.RFC3339), validated, w.Version, time.Now().UTC().Sub(*w.LastHeartbeat).Truncate(time.Second).String()})
 					if err != nil {
 						log.Fatalf("failed to write raw output : %s", err)
 					}

+ 3 - 0
cmd/crowdsec/output.go

@@ -100,6 +100,9 @@ func runOutput(input chan types.Event, overflow chan types.Event, buckets *leaky
 	}); err != nil {
 		return errors.Wrapf(err, "authenticate watcher (%s)", apiConfig.Login)
 	}
+	//start the heartbeat service
+	log.Debugf("Starting HeartBeat service")
+	Client.HeartBeat.StartHeartBeat(context.Background(), &outputsTomb)
 LOOP:
 	for {
 		select {

+ 1 - 0
go.sum

@@ -1035,6 +1035,7 @@ golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc
 golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
 golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.1.9-0.20211216111533-8d383106f7e7 h1:M1gcVrIb2lSn2FIL19DG0+/b8nNVKJ7W7b4WcAGZAYM=
 golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

+ 4 - 0
pkg/apiclient/client.go

@@ -32,6 +32,7 @@ type ApiClient struct {
 	Auth      *AuthService
 	Metrics   *MetricsService
 	Signal    *SignalService
+	HeartBeat *HeartBeatService
 }
 
 type service struct {
@@ -56,6 +57,7 @@ func NewClient(config *Config) (*ApiClient, error) {
 	c.Auth = (*AuthService)(&c.common)
 	c.Metrics = (*MetricsService)(&c.common)
 	c.Signal = (*SignalService)(&c.common)
+	c.HeartBeat = (*HeartBeatService)(&c.common)
 
 	return c, nil
 }
@@ -75,6 +77,8 @@ func NewDefaultClient(URL *url.URL, prefix string, userAgent string, client *htt
 	c.Auth = (*AuthService)(&c.common)
 	c.Metrics = (*MetricsService)(&c.common)
 	c.Signal = (*SignalService)(&c.common)
+	c.HeartBeat = (*HeartBeatService)(&c.common)
+
 	return c, nil
 }
 

+ 61 - 0
pkg/apiclient/heartbeat.go

@@ -0,0 +1,61 @@
+package apiclient
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"time"
+
+	"github.com/crowdsecurity/crowdsec/pkg/types"
+	log "github.com/sirupsen/logrus"
+	tomb "gopkg.in/tomb.v2"
+)
+
+type HeartBeatService service
+
+func (h *HeartBeatService) Ping(ctx context.Context) (bool, *Response, error) {
+
+	u := fmt.Sprintf("%s/heartbeat", h.client.URLPrefix)
+
+	req, err := h.client.NewRequest("GET", u, nil)
+	if err != nil {
+		return false, nil, err
+	}
+
+	resp, err := h.client.Do(ctx, req, nil)
+	if err != nil {
+		return false, resp, err
+	}
+
+	return true, resp, nil
+}
+
+func (h *HeartBeatService) StartHeartBeat(ctx context.Context, t *tomb.Tomb) {
+	t.Go(func() error {
+		defer types.CatchPanic("crowdsec/apiClient/heartbeat")
+		hbTimer := time.NewTicker(1 * time.Minute)
+		for {
+			select {
+			case <-hbTimer.C:
+				log.Debug("heartbeat: sending heartbeat")
+				ok, resp, err := h.Ping(ctx)
+				if err != nil {
+					log.Errorf("heartbeat error : %s", err)
+					continue
+				}
+				if resp.Response.StatusCode != http.StatusOK {
+					log.Errorf("heartbeat unexpected return code : %d", resp.Response.StatusCode)
+					continue
+				}
+				if !ok {
+					log.Errorf("heartbeat returned false")
+					continue
+				}
+			case <-t.Dying():
+				log.Debugf("heartbeat: stopping")
+				hbTimer.Stop()
+				return nil
+			}
+		}
+	})
+}

+ 1 - 0
pkg/apiserver/controllers/controller.go

@@ -87,6 +87,7 @@ func (c *Controller) NewV1() error {
 		jwtAuth.DELETE("/alerts", handlerV1.DeleteAlerts)
 		jwtAuth.DELETE("/decisions", handlerV1.DeleteDecisions)
 		jwtAuth.DELETE("/decisions/:decision_id", handlerV1.DeleteDecisionById)
+		jwtAuth.GET("/heartbeat", handlerV1.HeartBeat)
 	}
 
 	apiKeyAuth := groupV1.Group("")

+ 21 - 0
pkg/apiserver/controllers/v1/heartbeat.go

@@ -0,0 +1,21 @@
+package v1
+
+import (
+	"net/http"
+
+	jwt "github.com/appleboy/gin-jwt/v2"
+	"github.com/gin-gonic/gin"
+)
+
+func (c *Controller) HeartBeat(gctx *gin.Context) {
+
+	claims := jwt.ExtractClaims(gctx)
+	/*TBD : use defines rather than hardcoded key to find back owner*/
+	machineID := claims["id"].(string)
+
+	if err := c.DBClient.UpdateMachineLastHeartBeat(machineID); err != nil {
+		c.HandleDBErrors(gctx, err)
+		return
+	}
+	gctx.Status(http.StatusOK)
+}

+ 17 - 0
pkg/apiserver/heartbeat_test.go

@@ -0,0 +1,17 @@
+package apiserver
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestHeartBeat(t *testing.T) {
+	lapi := SetupLAPITest(t)
+
+	w := lapi.RecordResponse("GET", "/v1/heartbeat", emptyBody)
+	assert.Equal(t, 200, w.Code)
+
+	w = lapi.RecordResponse("POST", "/v1/heartbeat", emptyBody)
+	assert.Equal(t, 405, w.Code)
+}

+ 14 - 1
pkg/database/ent/machine.go

@@ -22,6 +22,8 @@ type Machine struct {
 	UpdatedAt *time.Time `json:"updated_at,omitempty"`
 	// LastPush holds the value of the "last_push" field.
 	LastPush *time.Time `json:"last_push,omitempty"`
+	// LastHeartbeat holds the value of the "last_heartbeat" field.
+	LastHeartbeat *time.Time `json:"last_heartbeat,omitempty"`
 	// MachineId holds the value of the "machineId" field.
 	MachineId string `json:"machineId,omitempty"`
 	// Password holds the value of the "password" field.
@@ -70,7 +72,7 @@ func (*Machine) scanValues(columns []string) ([]interface{}, error) {
 			values[i] = new(sql.NullInt64)
 		case machine.FieldMachineId, machine.FieldPassword, machine.FieldIpAddress, machine.FieldScenarios, machine.FieldVersion, machine.FieldStatus:
 			values[i] = new(sql.NullString)
-		case machine.FieldCreatedAt, machine.FieldUpdatedAt, machine.FieldLastPush:
+		case machine.FieldCreatedAt, machine.FieldUpdatedAt, machine.FieldLastPush, machine.FieldLastHeartbeat:
 			values[i] = new(sql.NullTime)
 		default:
 			return nil, fmt.Errorf("unexpected column %q for type Machine", columns[i])
@@ -114,6 +116,13 @@ func (m *Machine) assignValues(columns []string, values []interface{}) error {
 				m.LastPush = new(time.Time)
 				*m.LastPush = value.Time
 			}
+		case machine.FieldLastHeartbeat:
+			if value, ok := values[i].(*sql.NullTime); !ok {
+				return fmt.Errorf("unexpected type %T for field last_heartbeat", values[i])
+			} else if value.Valid {
+				m.LastHeartbeat = new(time.Time)
+				*m.LastHeartbeat = value.Time
+			}
 		case machine.FieldMachineId:
 			if value, ok := values[i].(*sql.NullString); !ok {
 				return fmt.Errorf("unexpected type %T for field machineId", values[i])
@@ -201,6 +210,10 @@ func (m *Machine) String() string {
 		builder.WriteString(", last_push=")
 		builder.WriteString(v.Format(time.ANSIC))
 	}
+	if v := m.LastHeartbeat; v != nil {
+		builder.WriteString(", last_heartbeat=")
+		builder.WriteString(v.Format(time.ANSIC))
+	}
 	builder.WriteString(", machineId=")
 	builder.WriteString(m.MachineId)
 	builder.WriteString(", password=<sensitive>")

+ 7 - 0
pkg/database/ent/machine/machine.go

@@ -17,6 +17,8 @@ const (
 	FieldUpdatedAt = "updated_at"
 	// FieldLastPush holds the string denoting the last_push field in the database.
 	FieldLastPush = "last_push"
+	// FieldLastHeartbeat holds the string denoting the last_heartbeat field in the database.
+	FieldLastHeartbeat = "last_heartbeat"
 	// FieldMachineId holds the string denoting the machineid field in the database.
 	FieldMachineId = "machine_id"
 	// FieldPassword holds the string denoting the password field in the database.
@@ -50,6 +52,7 @@ var Columns = []string{
 	FieldCreatedAt,
 	FieldUpdatedAt,
 	FieldLastPush,
+	FieldLastHeartbeat,
 	FieldMachineId,
 	FieldPassword,
 	FieldIpAddress,
@@ -82,6 +85,10 @@ var (
 	DefaultLastPush func() time.Time
 	// UpdateDefaultLastPush holds the default value on update for the "last_push" field.
 	UpdateDefaultLastPush func() time.Time
+	// DefaultLastHeartbeat holds the default value on creation for the "last_heartbeat" field.
+	DefaultLastHeartbeat func() time.Time
+	// UpdateDefaultLastHeartbeat holds the default value on update for the "last_heartbeat" field.
+	UpdateDefaultLastHeartbeat func() time.Time
 	// ScenariosValidator is a validator for the "scenarios" field. It is called by the builders before save.
 	ScenariosValidator func(string) error
 	// DefaultIsValidated holds the default value on creation for the "isValidated" field.

+ 97 - 0
pkg/database/ent/machine/where.go

@@ -114,6 +114,13 @@ func LastPush(v time.Time) predicate.Machine {
 	})
 }
 
+// LastHeartbeat applies equality check predicate on the "last_heartbeat" field. It's identical to LastHeartbeatEQ.
+func LastHeartbeat(v time.Time) predicate.Machine {
+	return predicate.Machine(func(s *sql.Selector) {
+		s.Where(sql.EQ(s.C(FieldLastHeartbeat), v))
+	})
+}
+
 // MachineId applies equality check predicate on the "machineId" field. It's identical to MachineIdEQ.
 func MachineId(v string) predicate.Machine {
 	return predicate.Machine(func(s *sql.Selector) {
@@ -433,6 +440,96 @@ func LastPushNotNil() predicate.Machine {
 	})
 }
 
+// LastHeartbeatEQ applies the EQ predicate on the "last_heartbeat" field.
+func LastHeartbeatEQ(v time.Time) predicate.Machine {
+	return predicate.Machine(func(s *sql.Selector) {
+		s.Where(sql.EQ(s.C(FieldLastHeartbeat), v))
+	})
+}
+
+// LastHeartbeatNEQ applies the NEQ predicate on the "last_heartbeat" field.
+func LastHeartbeatNEQ(v time.Time) predicate.Machine {
+	return predicate.Machine(func(s *sql.Selector) {
+		s.Where(sql.NEQ(s.C(FieldLastHeartbeat), v))
+	})
+}
+
+// LastHeartbeatIn applies the In predicate on the "last_heartbeat" field.
+func LastHeartbeatIn(vs ...time.Time) predicate.Machine {
+	v := make([]interface{}, len(vs))
+	for i := range v {
+		v[i] = vs[i]
+	}
+	return predicate.Machine(func(s *sql.Selector) {
+		// if not arguments were provided, append the FALSE constants,
+		// since we can't apply "IN ()". This will make this predicate falsy.
+		if len(v) == 0 {
+			s.Where(sql.False())
+			return
+		}
+		s.Where(sql.In(s.C(FieldLastHeartbeat), v...))
+	})
+}
+
+// LastHeartbeatNotIn applies the NotIn predicate on the "last_heartbeat" field.
+func LastHeartbeatNotIn(vs ...time.Time) predicate.Machine {
+	v := make([]interface{}, len(vs))
+	for i := range v {
+		v[i] = vs[i]
+	}
+	return predicate.Machine(func(s *sql.Selector) {
+		// if not arguments were provided, append the FALSE constants,
+		// since we can't apply "IN ()". This will make this predicate falsy.
+		if len(v) == 0 {
+			s.Where(sql.False())
+			return
+		}
+		s.Where(sql.NotIn(s.C(FieldLastHeartbeat), v...))
+	})
+}
+
+// LastHeartbeatGT applies the GT predicate on the "last_heartbeat" field.
+func LastHeartbeatGT(v time.Time) predicate.Machine {
+	return predicate.Machine(func(s *sql.Selector) {
+		s.Where(sql.GT(s.C(FieldLastHeartbeat), v))
+	})
+}
+
+// LastHeartbeatGTE applies the GTE predicate on the "last_heartbeat" field.
+func LastHeartbeatGTE(v time.Time) predicate.Machine {
+	return predicate.Machine(func(s *sql.Selector) {
+		s.Where(sql.GTE(s.C(FieldLastHeartbeat), v))
+	})
+}
+
+// LastHeartbeatLT applies the LT predicate on the "last_heartbeat" field.
+func LastHeartbeatLT(v time.Time) predicate.Machine {
+	return predicate.Machine(func(s *sql.Selector) {
+		s.Where(sql.LT(s.C(FieldLastHeartbeat), v))
+	})
+}
+
+// LastHeartbeatLTE applies the LTE predicate on the "last_heartbeat" field.
+func LastHeartbeatLTE(v time.Time) predicate.Machine {
+	return predicate.Machine(func(s *sql.Selector) {
+		s.Where(sql.LTE(s.C(FieldLastHeartbeat), v))
+	})
+}
+
+// LastHeartbeatIsNil applies the IsNil predicate on the "last_heartbeat" field.
+func LastHeartbeatIsNil() predicate.Machine {
+	return predicate.Machine(func(s *sql.Selector) {
+		s.Where(sql.IsNull(s.C(FieldLastHeartbeat)))
+	})
+}
+
+// LastHeartbeatNotNil applies the NotNil predicate on the "last_heartbeat" field.
+func LastHeartbeatNotNil() predicate.Machine {
+	return predicate.Machine(func(s *sql.Selector) {
+		s.Where(sql.NotNull(s.C(FieldLastHeartbeat)))
+	})
+}
+
 // MachineIdEQ applies the EQ predicate on the "machineId" field.
 func MachineIdEQ(v string) predicate.Machine {
 	return predicate.Machine(func(s *sql.Selector) {

+ 26 - 0
pkg/database/ent/machine_create.go

@@ -63,6 +63,20 @@ func (mc *MachineCreate) SetNillableLastPush(t *time.Time) *MachineCreate {
 	return mc
 }
 
+// SetLastHeartbeat sets the "last_heartbeat" field.
+func (mc *MachineCreate) SetLastHeartbeat(t time.Time) *MachineCreate {
+	mc.mutation.SetLastHeartbeat(t)
+	return mc
+}
+
+// SetNillableLastHeartbeat sets the "last_heartbeat" field if the given value is not nil.
+func (mc *MachineCreate) SetNillableLastHeartbeat(t *time.Time) *MachineCreate {
+	if t != nil {
+		mc.SetLastHeartbeat(*t)
+	}
+	return mc
+}
+
 // SetMachineId sets the "machineId" field.
 func (mc *MachineCreate) SetMachineId(s string) *MachineCreate {
 	mc.mutation.SetMachineId(s)
@@ -235,6 +249,10 @@ func (mc *MachineCreate) defaults() {
 		v := machine.DefaultLastPush()
 		mc.mutation.SetLastPush(v)
 	}
+	if _, ok := mc.mutation.LastHeartbeat(); !ok {
+		v := machine.DefaultLastHeartbeat()
+		mc.mutation.SetLastHeartbeat(v)
+	}
 	if _, ok := mc.mutation.IsValidated(); !ok {
 		v := machine.DefaultIsValidated
 		mc.mutation.SetIsValidated(v)
@@ -311,6 +329,14 @@ func (mc *MachineCreate) createSpec() (*Machine, *sqlgraph.CreateSpec) {
 		})
 		_node.LastPush = &value
 	}
+	if value, ok := mc.mutation.LastHeartbeat(); ok {
+		_spec.Fields = append(_spec.Fields, &sqlgraph.FieldSpec{
+			Type:   field.TypeTime,
+			Value:  value,
+			Column: machine.FieldLastHeartbeat,
+		})
+		_node.LastHeartbeat = &value
+	}
 	if value, ok := mc.mutation.MachineId(); ok {
 		_spec.Fields = append(_spec.Fields, &sqlgraph.FieldSpec{
 			Type:   field.TypeString,

+ 58 - 0
pkg/database/ent/machine_update.go

@@ -65,6 +65,18 @@ func (mu *MachineUpdate) ClearLastPush() *MachineUpdate {
 	return mu
 }
 
+// SetLastHeartbeat sets the "last_heartbeat" field.
+func (mu *MachineUpdate) SetLastHeartbeat(t time.Time) *MachineUpdate {
+	mu.mutation.SetLastHeartbeat(t)
+	return mu
+}
+
+// ClearLastHeartbeat clears the value of the "last_heartbeat" field.
+func (mu *MachineUpdate) ClearLastHeartbeat() *MachineUpdate {
+	mu.mutation.ClearLastHeartbeat()
+	return mu
+}
+
 // SetMachineId sets the "machineId" field.
 func (mu *MachineUpdate) SetMachineId(s string) *MachineUpdate {
 	mu.mutation.SetMachineId(s)
@@ -273,6 +285,10 @@ func (mu *MachineUpdate) defaults() {
 		v := machine.UpdateDefaultLastPush()
 		mu.mutation.SetLastPush(v)
 	}
+	if _, ok := mu.mutation.LastHeartbeat(); !ok && !mu.mutation.LastHeartbeatCleared() {
+		v := machine.UpdateDefaultLastHeartbeat()
+		mu.mutation.SetLastHeartbeat(v)
+	}
 }
 
 // check runs all checks and user-defined validators on the builder.
@@ -342,6 +358,19 @@ func (mu *MachineUpdate) sqlSave(ctx context.Context) (n int, err error) {
 			Column: machine.FieldLastPush,
 		})
 	}
+	if value, ok := mu.mutation.LastHeartbeat(); ok {
+		_spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{
+			Type:   field.TypeTime,
+			Value:  value,
+			Column: machine.FieldLastHeartbeat,
+		})
+	}
+	if mu.mutation.LastHeartbeatCleared() {
+		_spec.Fields.Clear = append(_spec.Fields.Clear, &sqlgraph.FieldSpec{
+			Type:   field.TypeTime,
+			Column: machine.FieldLastHeartbeat,
+		})
+	}
 	if value, ok := mu.mutation.MachineId(); ok {
 		_spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{
 			Type:   field.TypeString,
@@ -518,6 +547,18 @@ func (muo *MachineUpdateOne) ClearLastPush() *MachineUpdateOne {
 	return muo
 }
 
+// SetLastHeartbeat sets the "last_heartbeat" field.
+func (muo *MachineUpdateOne) SetLastHeartbeat(t time.Time) *MachineUpdateOne {
+	muo.mutation.SetLastHeartbeat(t)
+	return muo
+}
+
+// ClearLastHeartbeat clears the value of the "last_heartbeat" field.
+func (muo *MachineUpdateOne) ClearLastHeartbeat() *MachineUpdateOne {
+	muo.mutation.ClearLastHeartbeat()
+	return muo
+}
+
 // SetMachineId sets the "machineId" field.
 func (muo *MachineUpdateOne) SetMachineId(s string) *MachineUpdateOne {
 	muo.mutation.SetMachineId(s)
@@ -733,6 +774,10 @@ func (muo *MachineUpdateOne) defaults() {
 		v := machine.UpdateDefaultLastPush()
 		muo.mutation.SetLastPush(v)
 	}
+	if _, ok := muo.mutation.LastHeartbeat(); !ok && !muo.mutation.LastHeartbeatCleared() {
+		v := machine.UpdateDefaultLastHeartbeat()
+		muo.mutation.SetLastHeartbeat(v)
+	}
 }
 
 // check runs all checks and user-defined validators on the builder.
@@ -819,6 +864,19 @@ func (muo *MachineUpdateOne) sqlSave(ctx context.Context) (_node *Machine, err e
 			Column: machine.FieldLastPush,
 		})
 	}
+	if value, ok := muo.mutation.LastHeartbeat(); ok {
+		_spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{
+			Type:   field.TypeTime,
+			Value:  value,
+			Column: machine.FieldLastHeartbeat,
+		})
+	}
+	if muo.mutation.LastHeartbeatCleared() {
+		_spec.Fields.Clear = append(_spec.Fields.Clear, &sqlgraph.FieldSpec{
+			Type:   field.TypeTime,
+			Column: machine.FieldLastHeartbeat,
+		})
+	}
 	if value, ok := muo.mutation.MachineId(); ok {
 		_spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{
 			Type:   field.TypeString,

+ 1 - 0
pkg/database/ent/migrate/schema.go

@@ -155,6 +155,7 @@ var (
 		{Name: "created_at", Type: field.TypeTime, Nullable: true},
 		{Name: "updated_at", Type: field.TypeTime, Nullable: true},
 		{Name: "last_push", Type: field.TypeTime, Nullable: true},
+		{Name: "last_heartbeat", Type: field.TypeTime, Nullable: true},
 		{Name: "machine_id", Type: field.TypeString, Unique: true},
 		{Name: "password", Type: field.TypeString},
 		{Name: "ip_address", Type: field.TypeString},

+ 94 - 21
pkg/database/ent/mutation.go

@@ -5232,26 +5232,27 @@ func (m *EventMutation) ResetEdge(name string) error {
 // MachineMutation represents an operation that mutates the Machine nodes in the graph.
 type MachineMutation struct {
 	config
-	op            Op
-	typ           string
-	id            *int
-	created_at    *time.Time
-	updated_at    *time.Time
-	last_push     *time.Time
-	machineId     *string
-	password      *string
-	ipAddress     *string
-	scenarios     *string
-	version       *string
-	isValidated   *bool
-	status        *string
-	clearedFields map[string]struct{}
-	alerts        map[int]struct{}
-	removedalerts map[int]struct{}
-	clearedalerts bool
-	done          bool
-	oldValue      func(context.Context) (*Machine, error)
-	predicates    []predicate.Machine
+	op             Op
+	typ            string
+	id             *int
+	created_at     *time.Time
+	updated_at     *time.Time
+	last_push      *time.Time
+	last_heartbeat *time.Time
+	machineId      *string
+	password       *string
+	ipAddress      *string
+	scenarios      *string
+	version        *string
+	isValidated    *bool
+	status         *string
+	clearedFields  map[string]struct{}
+	alerts         map[int]struct{}
+	removedalerts  map[int]struct{}
+	clearedalerts  bool
+	done           bool
+	oldValue       func(context.Context) (*Machine, error)
+	predicates     []predicate.Machine
 }
 
 var _ ent.Mutation = (*MachineMutation)(nil)
@@ -5499,6 +5500,55 @@ func (m *MachineMutation) ResetLastPush() {
 	delete(m.clearedFields, machine.FieldLastPush)
 }
 
+// SetLastHeartbeat sets the "last_heartbeat" field.
+func (m *MachineMutation) SetLastHeartbeat(t time.Time) {
+	m.last_heartbeat = &t
+}
+
+// LastHeartbeat returns the value of the "last_heartbeat" field in the mutation.
+func (m *MachineMutation) LastHeartbeat() (r time.Time, exists bool) {
+	v := m.last_heartbeat
+	if v == nil {
+		return
+	}
+	return *v, true
+}
+
+// OldLastHeartbeat returns the old "last_heartbeat" field's value of the Machine entity.
+// If the Machine object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *MachineMutation) OldLastHeartbeat(ctx context.Context) (v *time.Time, err error) {
+	if !m.op.Is(OpUpdateOne) {
+		return v, errors.New("OldLastHeartbeat is only allowed on UpdateOne operations")
+	}
+	if m.id == nil || m.oldValue == nil {
+		return v, errors.New("OldLastHeartbeat requires an ID field in the mutation")
+	}
+	oldValue, err := m.oldValue(ctx)
+	if err != nil {
+		return v, fmt.Errorf("querying old value for OldLastHeartbeat: %w", err)
+	}
+	return oldValue.LastHeartbeat, nil
+}
+
+// ClearLastHeartbeat clears the value of the "last_heartbeat" field.
+func (m *MachineMutation) ClearLastHeartbeat() {
+	m.last_heartbeat = nil
+	m.clearedFields[machine.FieldLastHeartbeat] = struct{}{}
+}
+
+// LastHeartbeatCleared returns if the "last_heartbeat" field was cleared in this mutation.
+func (m *MachineMutation) LastHeartbeatCleared() bool {
+	_, ok := m.clearedFields[machine.FieldLastHeartbeat]
+	return ok
+}
+
+// ResetLastHeartbeat resets all changes to the "last_heartbeat" field.
+func (m *MachineMutation) ResetLastHeartbeat() {
+	m.last_heartbeat = nil
+	delete(m.clearedFields, machine.FieldLastHeartbeat)
+}
+
 // SetMachineId sets the "machineId" field.
 func (m *MachineMutation) SetMachineId(s string) {
 	m.machineId = &s
@@ -5863,7 +5913,7 @@ func (m *MachineMutation) Type() string {
 // order to get all numeric fields that were incremented/decremented, call
 // AddedFields().
 func (m *MachineMutation) Fields() []string {
-	fields := make([]string, 0, 10)
+	fields := make([]string, 0, 11)
 	if m.created_at != nil {
 		fields = append(fields, machine.FieldCreatedAt)
 	}
@@ -5873,6 +5923,9 @@ func (m *MachineMutation) Fields() []string {
 	if m.last_push != nil {
 		fields = append(fields, machine.FieldLastPush)
 	}
+	if m.last_heartbeat != nil {
+		fields = append(fields, machine.FieldLastHeartbeat)
+	}
 	if m.machineId != nil {
 		fields = append(fields, machine.FieldMachineId)
 	}
@@ -5908,6 +5961,8 @@ func (m *MachineMutation) Field(name string) (ent.Value, bool) {
 		return m.UpdatedAt()
 	case machine.FieldLastPush:
 		return m.LastPush()
+	case machine.FieldLastHeartbeat:
+		return m.LastHeartbeat()
 	case machine.FieldMachineId:
 		return m.MachineId()
 	case machine.FieldPassword:
@@ -5937,6 +5992,8 @@ func (m *MachineMutation) OldField(ctx context.Context, name string) (ent.Value,
 		return m.OldUpdatedAt(ctx)
 	case machine.FieldLastPush:
 		return m.OldLastPush(ctx)
+	case machine.FieldLastHeartbeat:
+		return m.OldLastHeartbeat(ctx)
 	case machine.FieldMachineId:
 		return m.OldMachineId(ctx)
 	case machine.FieldPassword:
@@ -5981,6 +6038,13 @@ func (m *MachineMutation) SetField(name string, value ent.Value) error {
 		}
 		m.SetLastPush(v)
 		return nil
+	case machine.FieldLastHeartbeat:
+		v, ok := value.(time.Time)
+		if !ok {
+			return fmt.Errorf("unexpected type %T for field %s", value, name)
+		}
+		m.SetLastHeartbeat(v)
+		return nil
 	case machine.FieldMachineId:
 		v, ok := value.(string)
 		if !ok {
@@ -6069,6 +6133,9 @@ func (m *MachineMutation) ClearedFields() []string {
 	if m.FieldCleared(machine.FieldLastPush) {
 		fields = append(fields, machine.FieldLastPush)
 	}
+	if m.FieldCleared(machine.FieldLastHeartbeat) {
+		fields = append(fields, machine.FieldLastHeartbeat)
+	}
 	if m.FieldCleared(machine.FieldScenarios) {
 		fields = append(fields, machine.FieldScenarios)
 	}
@@ -6101,6 +6168,9 @@ func (m *MachineMutation) ClearField(name string) error {
 	case machine.FieldLastPush:
 		m.ClearLastPush()
 		return nil
+	case machine.FieldLastHeartbeat:
+		m.ClearLastHeartbeat()
+		return nil
 	case machine.FieldScenarios:
 		m.ClearScenarios()
 		return nil
@@ -6127,6 +6197,9 @@ func (m *MachineMutation) ResetField(name string) error {
 	case machine.FieldLastPush:
 		m.ResetLastPush()
 		return nil
+	case machine.FieldLastHeartbeat:
+		m.ResetLastHeartbeat()
+		return nil
 	case machine.FieldMachineId:
 		m.ResetMachineId()
 		return nil

+ 8 - 2
pkg/database/ent/runtime.go

@@ -138,12 +138,18 @@ func init() {
 	machine.DefaultLastPush = machineDescLastPush.Default.(func() time.Time)
 	// machine.UpdateDefaultLastPush holds the default value on update for the last_push field.
 	machine.UpdateDefaultLastPush = machineDescLastPush.UpdateDefault.(func() time.Time)
+	// machineDescLastHeartbeat is the schema descriptor for last_heartbeat field.
+	machineDescLastHeartbeat := machineFields[3].Descriptor()
+	// machine.DefaultLastHeartbeat holds the default value on creation for the last_heartbeat field.
+	machine.DefaultLastHeartbeat = machineDescLastHeartbeat.Default.(func() time.Time)
+	// machine.UpdateDefaultLastHeartbeat holds the default value on update for the last_heartbeat field.
+	machine.UpdateDefaultLastHeartbeat = machineDescLastHeartbeat.UpdateDefault.(func() time.Time)
 	// machineDescScenarios is the schema descriptor for scenarios field.
-	machineDescScenarios := machineFields[6].Descriptor()
+	machineDescScenarios := machineFields[7].Descriptor()
 	// machine.ScenariosValidator is a validator for the "scenarios" field. It is called by the builders before save.
 	machine.ScenariosValidator = machineDescScenarios.Validators[0].(func(string) error)
 	// machineDescIsValidated is the schema descriptor for isValidated field.
-	machineDescIsValidated := machineFields[8].Descriptor()
+	machineDescIsValidated := machineFields[9].Descriptor()
 	// machine.DefaultIsValidated holds the default value on creation for the isValidated field.
 	machine.DefaultIsValidated = machineDescIsValidated.Default.(bool)
 	metaFields := schema.Meta{}.Fields()

+ 3 - 0
pkg/database/ent/schema/machine.go

@@ -24,6 +24,9 @@ func (Machine) Fields() []ent.Field {
 		field.Time("last_push").
 			Default(types.UtcNow).
 			UpdateDefault(types.UtcNow).Nillable().Optional(),
+		field.Time("last_heartbeat").
+			Default(types.UtcNow).
+			UpdateDefault(types.UtcNow).Nillable().Optional(),
 		field.String("machineId").Unique(),
 		field.String("password").Sensitive(),
 		field.String("ipAddress"),

+ 8 - 0
pkg/database/machines.go

@@ -118,6 +118,14 @@ func (c *Client) UpdateMachineLastPush(machineID string) error {
 	return nil
 }
 
+func (c *Client) UpdateMachineLastHeartBeat(machineID string) error {
+	_, err := c.Ent.Machine.Update().Where(machine.MachineIdEQ(machineID)).SetLastHeartbeat(time.Now().UTC()).Save(c.CTX)
+	if err != nil {
+		return errors.Wrapf(UpdateFail, "updating machine last_heartbeat: %s", err)
+	}
+	return nil
+}
+
 func (c *Client) UpdateMachineScenarios(scenarios string, ID int) error {
 	_, err := c.Ent.Machine.UpdateOneID(ID).
 		SetUpdatedAt(time.Now().UTC()).