mirror of
https://github.com/fleetdm/fleet
synced 2026-05-24 09:28:54 +00:00
287 lines
8.8 KiB
Go
287 lines
8.8 KiB
Go
// Package mysql provides the MySQL datastore implementation for the activity bounded context.
|
|
package mysql
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/activity/api"
|
|
"github.com/fleetdm/fleet/v4/server/activity/internal/types"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
|
platform_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
|
|
"github.com/jmoiron/sqlx"
|
|
"go.opentelemetry.io/otel"
|
|
)
|
|
|
|
// tracer is an OTEL tracer. It has no-op behavior when OTEL is not enabled.
|
|
var tracer = otel.Tracer("github.com/fleetdm/fleet/v4/server/activity/internal/mysql")
|
|
|
|
// Datastore is the MySQL implementation of the activity datastore.
|
|
type Datastore struct {
|
|
primary *sqlx.DB
|
|
replica *sqlx.DB
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewDatastore creates a new MySQL datastore for activities.
|
|
func NewDatastore(conns *platform_mysql.DBConnections, logger *slog.Logger) *Datastore {
|
|
return &Datastore{primary: conns.Primary, replica: conns.Replica, logger: logger}
|
|
}
|
|
|
|
func (ds *Datastore) reader(ctx context.Context) *sqlx.DB {
|
|
return ds.replica
|
|
}
|
|
|
|
// Ensure Datastore implements types.Datastore
|
|
var _ types.Datastore = (*Datastore)(nil)
|
|
|
|
// ListActivities returns a slice of activities performed across the organization.
|
|
func (ds *Datastore) ListActivities(ctx context.Context, opt types.ListOptions) ([]*api.Activity, *api.PaginationMetadata, error) {
|
|
ctx, span := tracer.Start(ctx, "activity.mysql.ListActivities")
|
|
defer span.End()
|
|
|
|
activitiesQ := `
|
|
SELECT
|
|
a.id,
|
|
a.user_id,
|
|
a.created_at,
|
|
a.activity_type,
|
|
a.user_name as name,
|
|
a.streamed,
|
|
a.user_email,
|
|
a.fleet_initiated
|
|
FROM activity_past a
|
|
WHERE a.host_only = false`
|
|
|
|
var args []any
|
|
|
|
if opt.Streamed != nil {
|
|
activitiesQ += " AND a.streamed = ?"
|
|
args = append(args, *opt.Streamed)
|
|
}
|
|
|
|
if opt.ActivityType != "" {
|
|
activitiesQ += " AND a.activity_type = ?"
|
|
args = append(args, opt.ActivityType)
|
|
}
|
|
|
|
// MatchQuery: search by user_name/user_email in activity table, plus user IDs from users table search
|
|
if opt.MatchQuery != "" {
|
|
activitiesQ += " AND (a.user_name LIKE ? OR a.user_email LIKE ?"
|
|
args = append(args, opt.MatchQuery+"%", opt.MatchQuery+"%")
|
|
|
|
// Add user IDs from users table search (populated by service via ACL)
|
|
if len(opt.MatchingUserIDs) > 0 {
|
|
inQ, inArgs, err := sqlx.In(" OR a.user_id IN (?)", opt.MatchingUserIDs)
|
|
if err != nil {
|
|
return nil, nil, ctxerr.Wrap(ctx, err, "bind user IDs for IN clause")
|
|
}
|
|
activitiesQ += inQ
|
|
args = append(args, inArgs...)
|
|
}
|
|
|
|
activitiesQ += ")"
|
|
}
|
|
|
|
if opt.StartCreatedAt != "" {
|
|
activitiesQ += " AND a.created_at >= ?"
|
|
args = append(args, opt.StartCreatedAt)
|
|
}
|
|
|
|
if opt.EndCreatedAt != "" {
|
|
activitiesQ += " AND a.created_at <= ?"
|
|
args = append(args, opt.EndCreatedAt)
|
|
} else if opt.StartCreatedAt != "" {
|
|
// When filtering by start date, cap at now to ensure consistent results
|
|
activitiesQ += " AND a.created_at <= ?"
|
|
args = append(args, time.Now().UTC())
|
|
}
|
|
|
|
// Apply pagination using platform_mysql
|
|
activitiesQ, args = platform_mysql.AppendListOptionsWithParams(activitiesQ, args, &opt)
|
|
|
|
var activities []*api.Activity
|
|
err := sqlx.SelectContext(ctx, ds.reader(ctx), &activities, activitiesQ, args...)
|
|
if err != nil {
|
|
return nil, nil, ctxerr.Wrap(ctx, err, "select activities")
|
|
}
|
|
|
|
// Fetch details as a separate query (due to MySQL sort buffer issues with large JSON)
|
|
if len(activities) > 0 {
|
|
if err := ds.fetchActivityDetails(ctx, activities); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
|
|
// Build pagination metadata
|
|
var meta *api.PaginationMetadata
|
|
if opt.IncludeMetadata {
|
|
meta = &api.PaginationMetadata{
|
|
HasPreviousResults: opt.Page > 0,
|
|
}
|
|
if uint(len(activities)) > opt.PerPage && opt.PerPage > 0 {
|
|
meta.HasNextResults = true
|
|
activities = activities[:len(activities)-1]
|
|
}
|
|
}
|
|
|
|
return activities, meta, nil
|
|
}
|
|
|
|
// MarkActivitiesAsStreamed marks the given activities as streamed.
|
|
func (ds *Datastore) MarkActivitiesAsStreamed(ctx context.Context, activityIDs []uint) error {
|
|
if len(activityIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
ctx, span := tracer.Start(ctx, "activity.mysql.MarkActivitiesAsStreamed")
|
|
defer span.End()
|
|
|
|
stmt := `UPDATE activity_past SET streamed = true WHERE id IN (?);`
|
|
query, args, err := sqlx.In(stmt, activityIDs)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "sqlx.In mark activities as streamed")
|
|
}
|
|
if _, err := ds.primary.ExecContext(ctx, query, args...); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "exec mark activities as streamed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListHostPastActivities returns past activities for a specific host.
|
|
func (ds *Datastore) ListHostPastActivities(ctx context.Context, hostID uint, opt types.ListOptions) ([]*api.Activity, *api.PaginationMetadata, error) {
|
|
ctx, span := tracer.Start(ctx, "activity.mysql.ListHostPastActivities")
|
|
defer span.End()
|
|
|
|
const listStmt = `
|
|
SELECT
|
|
ha.activity_id as id,
|
|
a.user_email as user_email,
|
|
a.user_name as name,
|
|
a.activity_type as activity_type,
|
|
a.details as details,
|
|
a.created_at as created_at,
|
|
a.user_id as user_id,
|
|
a.fleet_initiated as fleet_initiated
|
|
FROM
|
|
activity_host_past ha
|
|
JOIN activity_past a
|
|
ON ha.activity_id = a.id
|
|
WHERE
|
|
ha.host_id = ?`
|
|
|
|
args := []any{hostID}
|
|
|
|
stmt, args := platform_mysql.AppendListOptionsWithParams(listStmt, args, &opt)
|
|
|
|
var activities []*api.Activity
|
|
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &activities, stmt, args...); err != nil {
|
|
return nil, nil, ctxerr.Wrap(ctx, err, "select host past activities")
|
|
}
|
|
|
|
var metaData *api.PaginationMetadata
|
|
if opt.IncludeMetadata {
|
|
metaData = &api.PaginationMetadata{HasPreviousResults: opt.Page > 0}
|
|
if uint(len(activities)) > opt.PerPage { //nolint:gosec // dismiss G115
|
|
metaData.HasNextResults = true
|
|
activities = activities[:len(activities)-1]
|
|
}
|
|
}
|
|
|
|
return activities, metaData, nil
|
|
}
|
|
|
|
// CleanupExpiredActivities deletes up to maxCount activities older than expiryWindowDays
|
|
// that are not linked to any host. Host-linked activities are preserved.
|
|
func (ds *Datastore) CleanupExpiredActivities(ctx context.Context, maxCount int, expiryWindowDays int) error {
|
|
ctx, span := tracer.Start(ctx, "activity.mysql.CleanupExpiredActivities")
|
|
defer span.End()
|
|
|
|
const selectQuery = `
|
|
SELECT a.id FROM activity_past a
|
|
LEFT JOIN activity_host_past ha ON (a.id=ha.activity_id)
|
|
WHERE ha.activity_id IS NULL AND a.created_at < DATE_SUB(NOW(), INTERVAL ? DAY)
|
|
ORDER BY a.id ASC
|
|
LIMIT ?`
|
|
|
|
var activityIDs []uint
|
|
if err := sqlx.SelectContext(ctx, ds.primary, &activityIDs, selectQuery, expiryWindowDays, maxCount); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "select expired activities for deletion")
|
|
}
|
|
if len(activityIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
deleteQuery, args, err := sqlx.In(`DELETE FROM activity_past WHERE id IN (?)`, activityIDs)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "build expired activities IN query")
|
|
}
|
|
if _, err := ds.primary.ExecContext(ctx, deleteQuery, args...); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "delete expired activities")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CleanupHostActivities removes activity_host_past rows for the given host IDs.
|
|
func (ds *Datastore) CleanupHostActivities(ctx context.Context, hostIDs []uint) error {
|
|
ctx, span := tracer.Start(ctx, "activity.mysql.CleanupHostActivities")
|
|
defer span.End()
|
|
|
|
if len(hostIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
stmt, args, err := sqlx.In(`DELETE FROM activity_host_past WHERE host_id IN (?)`, hostIDs)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "build activity_host_past IN query")
|
|
}
|
|
if _, err := ds.primary.ExecContext(ctx, stmt, args...); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "delete activity_host_past for deleted hosts")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// fetchActivityDetails fetches details for activities in a separate query
|
|
// to avoid MySQL sort buffer issues with large JSON entries.
|
|
func (ds *Datastore) fetchActivityDetails(ctx context.Context, activities []*api.Activity) error {
|
|
ids := make([]uint, 0, len(activities))
|
|
for _, a := range activities {
|
|
ids = append(ids, a.ID)
|
|
}
|
|
|
|
detailsStmt, detailsArgs, err := sqlx.In("SELECT id, details FROM activity_past WHERE id IN (?)", ids)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "bind activity IDs for details")
|
|
}
|
|
|
|
type activityDetails struct {
|
|
ID uint `db:"id"`
|
|
Details *json.RawMessage `db:"details"`
|
|
}
|
|
var details []activityDetails
|
|
|
|
err = sqlx.SelectContext(ctx, ds.reader(ctx), &details, detailsStmt, detailsArgs...)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return ctxerr.Wrap(ctx, err, "select activity details")
|
|
}
|
|
|
|
detailsLookup := make(map[uint]*json.RawMessage, len(details))
|
|
for _, d := range details {
|
|
detailsLookup[d.ID] = d.Details
|
|
}
|
|
|
|
for _, a := range activities {
|
|
det, ok := detailsLookup[a.ID]
|
|
if !ok {
|
|
ds.logger.WarnContext(ctx, "Activity details not found", "activity_id", a.ID)
|
|
continue
|
|
}
|
|
a.Details = det
|
|
}
|
|
|
|
return nil
|
|
}
|