Files
Juan Fernandez fbccb8cc59 Emit created/deleted activities for setup experience scripts
Setup experience script add/replace/delete now record activities (API
and GitOps), skipping no-op re-submissions.
2026-07-23 06:41:51 -04:00

610 lines
20 KiB
Go

package seed
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"reflect"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
)
// ActivitiesOptions configures the activity seeder. DSN points at a Fleet
// MySQL instance; the seeder writes directly to activity_past (and
// activity_host_past for host-scoped rows) without going through the service
// layer, so no webhooks fire.
//
// The seeder is intentionally non-idempotent: every call creates new rows.
// All user-facing name-like fields are prefixed with "*" plus a per-run tag
// so faked rows are obvious in the UI and don't pile up under one name.
type ActivitiesOptions struct {
DSN string
ActorID uint
ActorName string
ActorEmail string
HostID uint
Batches int
// Category selects which subset of activity templates to seed. Empty or
// "all" runs every template. See ActivityCategories for the supported
// values.
Category string
}
// fakePrefix marks any string value the seeder writes so reviewers can tell
// at a glance the row was generated by dibble and not produced by a real
// flow. We hardcode the prefix in stringExample below; tests can compare
// against this constant.
const fakePrefix = "*"
// hostIDer matches fleet activities that report associated hosts. The
// interface mirrors what NewActivity does internally; defining it here
// avoids pulling in the fleet-internal activity bounded context.
type hostIDer interface {
HostIDs() []uint
}
// stringExample returns a deterministic example value for the given field,
// based on the json tag (preferred) or Go field name. Returned strings are
// prefixed with fakePrefix and tagged with runTag so the rendered UI copy
// makes it obvious the row is faked. Passing runTag explicitly (instead of
// reading a package-level global) keeps Activities reentrant.
//
// Lifted from tools/seed-activities/main.go (PR #45713) and adapted to
// always include the fake marker.
func stringExample(jsonTag, fieldName, runTag string) string {
name := strings.ToLower(jsonTag)
if name == "" {
name = strings.ToLower(fieldName)
}
// Some fields are not user-facing strings — they're enums or IDs the
// activity templates branch on. We return plain values for those so the
// "*" doesn't break the rendering.
switch {
case strings.Contains(name, "policy_critical"):
return "false"
case strings.Contains(name, "platform"):
return "darwin"
case strings.Contains(name, "status"):
return "installed"
case strings.Contains(name, "role"):
return "admin"
case strings.Contains(name, "mode"):
return "all"
case strings.Contains(name, "script_execution_id"),
strings.Contains(name, "command_uuid"),
strings.Contains(name, "install_uuid"),
strings.Contains(name, "uuid"):
return "00000000-0000-0000-0000-000000000001"
case strings.Contains(name, "url"):
return "https://example.com/"
}
tag := fakePrefix
if runTag != "" {
tag = fakePrefix + runTag + " "
}
switch {
case strings.Contains(name, "host_display") || name == "hostname":
return tag + "example-host"
case name == "software_display_name" || strings.HasPrefix(name, "software_title"):
return tag + "GitHub Desktop"
case strings.Contains(name, "software_package"):
return tag + "GitHubDesktop-arm64.dmg"
case strings.Contains(name, "software_icon_url"):
return "https://example.com/icon.png"
case strings.Contains(name, "app_store_id"):
return "497799835"
case strings.Contains(name, "team_name") || strings.Contains(name, "fleet_name"):
return tag + "Marketing"
case strings.Contains(name, "user_full") || strings.Contains(name, "actor_full"):
return tag + "Example User"
case strings.Contains(name, "user_email") || name == "email":
return tag + "user@example.com"
case strings.Contains(name, "user_name"):
return tag + "user@example.com"
case strings.Contains(name, "policy_name"):
return tag + "Failing policy"
case strings.Contains(name, "profile_name"):
return tag + "Example profile"
case strings.Contains(name, "label_name"):
return tag + "Example label"
case strings.Contains(name, "script_name"):
return tag + "example.sh"
case strings.Contains(name, "location"):
return tag + "United States"
case strings.Contains(name, "name"):
return tag + "Example name"
default:
return tag + "example"
}
}
// setExampleFields walks the activity's struct fields and assigns
// deterministic example values to anything left at its zero value. Hosts
// get the configured seed host id wired in; runTag is appended to
// name-like strings via stringExample.
func setExampleFields(activity any, hostID uint, runTag string) {
v := reflect.ValueOf(activity)
if v.Kind() != reflect.Ptr || v.Elem().Kind() != reflect.Struct {
return
}
s := v.Elem()
t := s.Type()
for i := 0; i < t.NumField(); i++ {
f := s.Field(i)
if !f.CanSet() {
continue
}
ft := t.Field(i)
jsonTag := strings.Split(ft.Tag.Get("json"), ",")[0]
nameLower := strings.ToLower(jsonTag)
if nameLower == "" {
nameLower = strings.ToLower(ft.Name)
}
switch f.Kind() {
case reflect.String:
if f.String() == "" {
f.SetString(stringExample(jsonTag, ft.Name, runTag))
}
case reflect.Bool:
// Self-service software activities are flipped to true so the
// passive-voice rendering can be inspected.
if nameLower == "self_service" {
f.SetBool(true)
}
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
if f.Uint() == 0 {
if strings.Contains(nameLower, "host_id") {
f.SetUint(uint64(hostID))
} else {
f.SetUint(1)
}
}
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
if f.Int() == 0 {
f.SetInt(1)
}
case reflect.Ptr:
if !f.IsNil() {
continue
}
elem := f.Type().Elem()
switch elem.Kind() {
case reflect.String:
val := stringExample(jsonTag, ft.Name, runTag)
f.Set(reflect.ValueOf(&val))
case reflect.Bool:
val := false
f.Set(reflect.ValueOf(&val))
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
val := uint(1)
if strings.Contains(nameLower, "host_id") {
val = hostID
}
ptr := reflect.New(elem)
ptr.Elem().SetUint(uint64(val))
f.Set(ptr)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
ptr := reflect.New(elem)
ptr.Elem().SetInt(1)
f.Set(ptr)
}
case reflect.Slice:
if !f.IsNil() {
continue
}
if ft.Name == "HostIDs" || nameLower == "host_ids" {
f.Set(reflect.ValueOf([]uint{hostID}))
}
}
}
}
// Activity category names accepted by Activities.Category. Keep in sync with
// activityTemplatesByCategory; ActivityCategories is the public-facing list
// used by the cobra subcommands.
const (
CategoryAll = "all"
CategorySettings = "settings"
CategoryProfiles = "profiles"
CategoryScripts = "scripts"
CategorySoftware = "software"
CategoryHosts = "hosts"
CategoryUsers = "users"
CategoryTeams = "teams"
CategoryPolicies = "policies"
CategoryQueries = "queries"
CategoryLabels = "labels"
CategoryCertificates = "certificates"
)
// ActivityCategories is the ordered list of valid Category values, excluding
// "all". The activities cobra command uses this to register one subcommand
// per category and to validate user input.
var ActivityCategories = []string{
CategorySettings,
CategoryProfiles,
CategoryScripts,
CategorySoftware,
CategoryHosts,
CategoryUsers,
CategoryTeams,
CategoryPolicies,
CategoryQueries,
CategoryLabels,
CategoryCertificates,
}
// activityTemplatesByCategory maps each category to its activity templates.
// Every activity type in server/fleet/activities.go belongs to exactly one
// category so `dibble activities all` writes one of each.
//
// To regenerate the full template list when upstream adds new activities,
// from the repo root:
//
// grep -E "^func \(a Activity[A-Za-z]+\) ActivityName" server/fleet/activities.go \
// | sed 's/^func (a \(Activity[A-Za-z]*\)) ActivityName.*/\tfleet.\1{},/' \
// | sort -u
//
// then sort the new entries into the right bucket below.
var activityTemplatesByCategory = map[string][]fleet.ActivityDetails{
CategorySettings: {
fleet.ActivityTypeEnabledActivityAutomations{},
fleet.ActivityTypeEditedActivityAutomations{},
fleet.ActivityTypeDisabledActivityAutomations{},
fleet.ActivityTypeEditedAgentOptions{},
fleet.ActivityTypeEditedMacOSMinVersion{},
fleet.ActivityTypeEnabledMacosUpdateNewHosts{},
fleet.ActivityTypeDisabledMacosUpdateNewHosts{},
fleet.ActivityTypeEditedWindowsUpdates{},
fleet.ActivityTypeEditedIOSMinVersion{},
fleet.ActivityTypeEditedIPadOSMinVersion{},
fleet.ActivityTypeEnabledMacosDiskEncryption{},
fleet.ActivityTypeDisabledMacosDiskEncryption{},
fleet.ActivityTypeEnabledRecoveryLockPasswords{},
fleet.ActivityTypeDisabledRecoveryLockPasswords{},
fleet.ActivityTypeEditedHostNameTemplate{},
fleet.ActivityTypeEnabledGitOpsMode{},
fleet.ActivityTypeDisabledGitOpsMode{},
fleet.ActivityTypeEnabledGitOpsException{},
fleet.ActivityTypeDisabledGitOpsException{},
fleet.ActivityTypeEnabledHistoricalDataset{},
fleet.ActivityTypeDisabledHistoricalDataset{},
fleet.ActivityTypeEnabledMacosSetupEndUserAuth{},
fleet.ActivityTypeDisabledMacosSetupEndUserAuth{},
fleet.ActivityTypeEnabledWindowsMDM{},
fleet.ActivityTypeDisabledWindowsMDM{},
fleet.ActivityTypeEnabledWindowsMDMMigration{},
fleet.ActivityTypeDisabledWindowsMDMMigration{},
fleet.ActivityTypeEnabledAndroidMDM{},
fleet.ActivityTypeDisabledAndroidMDM{},
fleet.ActivityTypeChangedOrgLogo{},
fleet.ActivityTypeDeletedOrgLogo{},
fleet.ActivityEnabledVPP{},
fleet.ActivityDisabledVPP{},
fleet.ActivityTypeAddedConditionalAccessIntegrationMicrosoft{},
fleet.ActivityTypeDeletedConditionalAccessIntegrationMicrosoft{},
fleet.ActivityTypeAddedConditionalAccessOkta{},
fleet.ActivityTypeDeletedConditionalAccessOkta{},
fleet.ActivityTypeEnabledConditionalAccessAutomations{},
fleet.ActivityTypeDisabledConditionalAccessAutomations{},
fleet.ActivityTypeUpdateConditionalAccessBypass{},
fleet.ActivityTypeAddedMicrosoftEntraTenant{},
fleet.ActivityTypeDeletedMicrosoftEntraTenant{},
fleet.ActivityTypeEditedEnrollSecrets{},
fleet.ActivityCreatedCustomVariable{},
fleet.ActivityDeletedCustomVariable{},
fleet.ActivityEditedSetupExperienceSoftware{},
},
CategoryProfiles: {
fleet.ActivityTypeCreatedMacosProfile{},
fleet.ActivityTypeDeletedMacosProfile{},
fleet.ActivityTypeEditedMacosProfile{},
fleet.ActivityTypeCreatedWindowsProfile{},
fleet.ActivityTypeDeletedWindowsProfile{},
fleet.ActivityTypeEditedWindowsProfile{},
fleet.ActivityTypeCreatedDeclarationProfile{},
fleet.ActivityTypeDeletedDeclarationProfile{},
fleet.ActivityTypeEditedDeclarationProfile{},
fleet.ActivityTypeCreatedAndroidProfile{},
fleet.ActivityTypeDeletedAndroidProfile{},
fleet.ActivityTypeEditedAndroidProfile{},
fleet.ActivityTypeResentConfigurationProfile{},
fleet.ActivityTypeResentConfigurationProfileBatch{},
fleet.ActivityTypeChangedMacosSetupAssistant{},
fleet.ActivityTypeDeletedMacosSetupAssistant{},
fleet.ActivityTypeAddedBootstrapPackage{},
fleet.ActivityTypeDeletedBootstrapPackage{},
fleet.ActivityTypeFailedEnrollmentProfileRenewal{},
},
CategoryScripts: {
fleet.ActivityTypeRanScript{},
fleet.ActivityTypeAddedScript{},
fleet.ActivityTypeUpdatedScript{},
fleet.ActivityTypeDeletedScript{},
fleet.ActivityTypeEditedScript{},
fleet.ActivityTypeCanceledRunScript{},
fleet.ActivityTypeRanScriptBatch{},
fleet.ActivityTypeBatchScriptScheduled{},
fleet.ActivityTypeBatchScriptCanceled{},
fleet.ActivityCreatedSetupExperienceScript{},
fleet.ActivityDeletedSetupExperienceScript{},
},
CategorySoftware: {
fleet.ActivityTypeInstalledSoftware{},
fleet.ActivityTypeUninstalledSoftware{},
fleet.ActivityTypeAddedSoftware{},
fleet.ActivityTypeEditedSoftware{},
fleet.ActivityTypeDeletedSoftware{},
fleet.ActivityTypeCanceledInstallSoftware{},
fleet.ActivityTypeCanceledUninstallSoftware{},
fleet.ActivityAddedAppStoreApp{},
fleet.ActivityDeletedAppStoreApp{},
fleet.ActivityInstalledAppStoreApp{},
fleet.ActivityEditedAppStoreApp{},
fleet.ActivityTypeCanceledInstallAppStoreApp{},
fleet.ActivityTypeCanceledSetupExperience{},
},
CategoryHosts: {
fleet.ActivityTypeDeletedHost{},
fleet.ActivityTypeFleetEnrolled{},
fleet.ActivityTypeMDMEnrolled{},
fleet.ActivityTypeMDMUnenrolled{},
fleet.ActivityTypeLockedHost{},
fleet.ActivityTypeUnlockedHost{},
fleet.ActivityTypeWipedHost{},
fleet.ActivityTypeWipeFailedHost{},
fleet.ActivityTypeReadHostDiskEncryptionKey{},
fleet.ActivityTypeEscrowedDiskEncryptionKey{},
fleet.ActivityTypeViewedHostRecoveryLockPassword{},
fleet.ActivityTypeSetHostRecoveryLockPassword{},
fleet.ActivityTypeRotatedHostRecoveryLockPassword{},
fleet.ActivityTypeCreatedManagedLocalAccount{},
fleet.ActivityTypeViewedManagedLocalAccount{},
fleet.ActivityTypeEnabledManagedLocalAccount{},
fleet.ActivityTypeDisabledManagedLocalAccount{},
fleet.ActivityTypeRotatedManagedLocalAccountPassword{},
fleet.ActivityTypeFailedToRotateManagedLocalAccountPassword{},
fleet.ActivityTypeHostBypassedConditionalAccess{},
fleet.ActivityTypeClearedPasscode{},
fleet.ActivityTypeEditedHostIdpData{},
},
CategoryUsers: {
fleet.ActivityTypeUserAddedBySSO{},
fleet.ActivityTypeUserLoggedIn{},
fleet.ActivityTypeUserFailedLogin{},
fleet.ActivityTypeCreatedUser{},
fleet.ActivityTypeDeletedUser{},
fleet.ActivityTypeChangedUserGlobalRole{},
fleet.ActivityTypeDeletedUserGlobalRole{},
fleet.ActivityTypeChangedUserTeamRole{},
fleet.ActivityTypeDeletedUserTeamRole{},
},
CategoryTeams: {
fleet.ActivityTypeCreatedTeam{},
fleet.ActivityTypeDeletedTeam{},
fleet.ActivityTypeAppliedSpecTeam{},
fleet.ActivityTypeTransferredHostsToTeam{},
},
CategoryPolicies: {
fleet.ActivityTypeCreatedPolicy{},
fleet.ActivityTypeEditedPolicy{},
fleet.ActivityTypeDeletedPolicy{},
fleet.ActivityTypeAppliedSpecPolicy{},
},
CategoryQueries: {
fleet.ActivityTypeCreatedSavedQuery{},
fleet.ActivityTypeEditedSavedQuery{},
fleet.ActivityTypeDeletedSavedQuery{},
fleet.ActivityTypeDeletedMultipleSavedQuery{},
fleet.ActivityTypeAppliedSpecSavedQuery{},
fleet.ActivityTypeLiveQuery{},
fleet.ActivityTypeCreatedPack{},
fleet.ActivityTypeEditedPack{},
fleet.ActivityTypeDeletedPack{},
fleet.ActivityTypeAppliedSpecPack{},
},
CategoryLabels: {
fleet.ActivityTypeCreatedLabel{},
fleet.ActivityTypeEditedLabel{},
fleet.ActivityTypeDeletedLabel{},
},
CategoryCertificates: {
fleet.ActivityAddedNDESSCEPProxy{},
fleet.ActivityDeletedNDESSCEPProxy{},
fleet.ActivityEditedNDESSCEPProxy{},
fleet.ActivityAddedCustomSCEPProxy{},
fleet.ActivityDeletedCustomSCEPProxy{},
fleet.ActivityEditedCustomSCEPProxy{},
fleet.ActivityAddedDigiCert{},
fleet.ActivityDeletedDigiCert{},
fleet.ActivityEditedDigiCert{},
fleet.ActivityAddedHydrant{},
fleet.ActivityDeletedHydrant{},
fleet.ActivityEditedHydrant{},
fleet.ActivityAddedCustomESTProxy{},
fleet.ActivityDeletedCustomESTProxy{},
fleet.ActivityEditedCustomESTProxy{},
fleet.ActivityAddedSmallstep{},
fleet.ActivityDeletedSmallstep{},
fleet.ActivityEditedSmallstep{},
fleet.ActivityTypeAddedCertificate{},
fleet.ActivityTypeDeletedCertificate{},
fleet.ActivityTypeInstalledCertificate{},
fleet.ActivityTypeResentCertificate{},
fleet.ActivityTypeEditedAndroidCertificate{},
},
}
// templatesForCategory returns the templates a single category covers, or
// every template (in a stable order) when category == "" or "all".
func templatesForCategory(category string) ([]fleet.ActivityDetails, error) {
if category == "" || category == CategoryAll {
out := make([]fleet.ActivityDetails, 0, 200)
for _, cat := range ActivityCategories {
out = append(out, activityTemplatesByCategory[cat]...)
}
return out, nil
}
tmpls, ok := activityTemplatesByCategory[category]
if !ok {
return nil, fmt.Errorf("unknown activity category %q (valid: all, %s)",
category, strings.Join(ActivityCategories, ", "))
}
return tmpls, nil
}
// insertActivity writes one row to activity_past plus one row per host id
// reported by the activity into activity_host_past. Both writes go through
// a single transaction so a host-mapping failure can't leave behind a
// partially-seeded activity row. Returns the new activity_past.id.
func insertActivity(
ctx context.Context, db *sql.DB,
actorID uint, actorName, actorEmail string,
activity fleet.ActivityDetails,
) (int64, error) {
details, err := json.Marshal(activity)
if err != nil {
return 0, fmt.Errorf("marshal %T: %w", activity, err)
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return 0, fmt.Errorf("begin tx: %w", err)
}
// Defer a rollback that's a no-op once we've committed.
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
const insert = `INSERT INTO activity_past
(user_id, user_name, user_email, activity_type, details, fleet_initiated)
VALUES (?, ?, ?, ?, ?, 0)`
res, err := tx.ExecContext(ctx, insert,
actorID, actorName, actorEmail, activity.ActivityName(), details)
if err != nil {
return 0, fmt.Errorf("insert %s: %w", activity.ActivityName(), err)
}
actID, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("last insert id for %s: %w", activity.ActivityName(), err)
}
if h, ok := activity.(hostIDer); ok {
ids := h.HostIDs()
if len(ids) > 0 {
const insertHost = `INSERT INTO activity_host_past (host_id, activity_id) VALUES (?, ?)`
for _, hid := range ids {
if _, err := tx.ExecContext(ctx, insertHost, hid, actID); err != nil {
return 0, fmt.Errorf("insert activity_host_past %d/%d: %w", hid, actID, err)
}
}
}
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("commit activity %s: %w", activity.ActivityName(), err)
}
committed = true
return actID, nil
}
// Activities writes a fresh batch of fake activities to MySQL on every call.
// All user-facing name fields are prefixed with "*" and tagged with the
// current run id so seeded rows are obvious in the UI and don't conflate
// across runs.
func Activities(ctx context.Context, log Logger, opt ActivitiesOptions) Result {
res := Result{Entity: "activities"}
if opt.DSN == "" {
res.Errors = append(res.Errors, errors.New("activities: empty DSN"))
return res
}
if opt.Batches <= 0 {
opt.Batches = 1
}
if opt.HostID == 0 {
opt.HostID = 1
}
if opt.ActorName == "" {
opt.ActorName = "*Dibble Admin"
}
if opt.ActorEmail == "" {
opt.ActorEmail = "*admin@example.com"
}
if opt.ActorID == 0 {
opt.ActorID = 1
}
dsn, err := mysqlDSN(opt.DSN, false)
if err != nil {
res.Errors = append(res.Errors, fmt.Errorf("parse DSN: %w", err))
return res
}
db, err := sql.Open("mysql", dsn)
if err != nil {
res.Errors = append(res.Errors, fmt.Errorf("open mysql: %w", err))
return res
}
defer db.Close()
if err := db.PingContext(ctx); err != nil {
res.Errors = append(res.Errors, fmt.Errorf("mysql ping: %w", err))
return res
}
// Verify the actor user exists; activity_past.user_id has an FK that
// would otherwise fire on insert with a confusing error.
var actorExists bool
row := db.QueryRowContext(ctx, "SELECT 1 FROM users WHERE id = ?", opt.ActorID)
if err := row.Scan(&actorExists); err != nil {
if errors.Is(err, sql.ErrNoRows) {
res.Errors = append(res.Errors,
fmt.Errorf("actor user id=%d not found in users table — pass -actor-id or seed users first", opt.ActorID))
return res
}
res.Errors = append(res.Errors, fmt.Errorf("check actor user: %w", err))
return res
}
templates, err := templatesForCategory(opt.Category)
if err != nil {
res.Errors = append(res.Errors, err)
return res
}
for b := 0; b < opt.Batches; b++ {
runTag := fmt.Sprintf("%d-%d", time.Now().UnixNano()%1_000_000, b+1)
for _, tmpl := range templates {
ptr := reflect.New(reflect.TypeOf(tmpl))
ptr.Elem().Set(reflect.ValueOf(tmpl))
setExampleFields(ptr.Interface(), opt.HostID, runTag)
filled := ptr.Elem().Interface().(fleet.ActivityDetails)
if _, err := insertActivity(ctx, db,
opt.ActorID, opt.ActorName, opt.ActorEmail, filled); err != nil {
res.Errors = append(res.Errors, err)
continue
}
res.Created++
}
category := opt.Category
if category == "" {
category = CategoryAll
}
log.Printf("activities: seeded batch %d/%d (category=%s tag=%s)",
b+1, opt.Batches, category, runTag)
}
return res
}