Setup experience script add/replace/delete now record activities (API and GitOps), skipping no-op re-submissions.
610 lines
20 KiB
Go
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
|
|
}
|