fleet/server/activity/internal/mysql/activity.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
}