## Overview `dibble` is a one-stop CLI for seeding a Fleet server with test data — users, teams, policies, reports, labels, scripts, MDM profiles, software, secrets, CAs, and vulns — replacing ~8 ad-hoc seeding tools with a single binary. It makes it easy to: - **Spin up a populated dev/test server in one command** — `dibble all` plants everything with sensible, idempotent defaults. - **Skip the flag-memorization** — running `dibble` with no args launches an interactive wizard that prompts for Fleet URL, API token, theme, and which entities to seed, and offers to save the config to `~/.dibble.yaml`. - **Seed individual entity types** — `dibble users`, `dibble teams`, `dibble policies`, etc., when you only need one slice. - **Get themed, recognizable test data** — pick a theme (hitchhikers, tng, lotr, ghibli, parksrec, …) so seeded names are easy to eyeball in the UI. Hosts are intentionally out of scope — `cmd/osquery-perf` still owns that. `dibble hosts` is a thin convenience wrapper that picks a fleet, fetches its enroll secret, and prints/runs the osquery-perf invocation for you. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Dibble: a CLI tool to seed realistic test data, including an interactive wizard and subcommands for teams/users/software/policies/scripts/reports/profiles/labels/activities/enroll-secrets/hosts/vulns, plus theme-driven “cas” and “ping”. * Theme system: multiple curated themes to generate consistent seeded identities, policies, software, labels, and scripts. * **Chores** * Ignored the built dibble binary and added a Makefile build target to compile the dibble tool. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
366 lines
9.6 KiB
Go
366 lines
9.6 KiB
Go
package command
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/AlecAivazis/survey/v2"
|
|
"github.com/spf13/cobra"
|
|
"github.com/spf13/viper"
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"github.com/fleetdm/fleet/v4/tools/dibble/pkg/themes"
|
|
)
|
|
|
|
// runWizard drives the interactive no-arg flow. The cobra root command is
|
|
// passed in so the wizard can invoke `runAll` against the same client setup
|
|
// the subcommands use.
|
|
func runWizard(root *cobra.Command) error {
|
|
// Cobra hasn't run yet (we bypassed Execute), so initialize viper manually.
|
|
initConfig()
|
|
|
|
fmt.Fprint(stdoutish, themes.TapirSmall)
|
|
fmt.Fprintln(stdoutish, "Welcome to dibble. Let's plant some seeds.")
|
|
|
|
// Step 1: resolve config (URL + token), prompting only for what's missing.
|
|
urlAsked, tokenAsked, err := promptConfig()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// We defer the "save config?" prompt until after theme and the
|
|
// optional --insecure toggle so the persisted file reflects ALL of
|
|
// the user's final choices, not just URL+token.
|
|
shouldOfferSave := urlAsked || tokenAsked
|
|
|
|
// Step 2: ping. Fail fast so the user knows their config is wrong before
|
|
// they spend time picking themes. If a TLS verification error trips us
|
|
// up against a self-signed dev cert, offer to flip --insecure.
|
|
client, err := newClientFromViper()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var ver struct {
|
|
Version string `json:"version"`
|
|
}
|
|
pingErr := client.Get("/api/latest/fleet/version", &ver)
|
|
if pingErr != nil && isTLSVerificationError(pingErr) && !viper.GetBool(keyInsecure) {
|
|
retry := true
|
|
if err := survey.AskOne(
|
|
&survey.Confirm{Message: "TLS cert isn't trusted. Skip verification (insecure)?", Default: true},
|
|
&retry,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
if retry {
|
|
viper.Set(keyInsecure, true)
|
|
client, err = newClientFromViper()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pingErr = client.Get("/api/latest/fleet/version", &ver)
|
|
}
|
|
}
|
|
if pingErr != nil {
|
|
return fmt.Errorf("could not reach Fleet at %s: %w", viper.GetString(keyFleetURL), pingErr)
|
|
}
|
|
printf("connected to Fleet %s ✓", ver.Version)
|
|
|
|
// Step 3: theme picker.
|
|
chosenTheme := viper.GetString(keyTheme)
|
|
if err := survey.AskOne(
|
|
&survey.Select{
|
|
Message: "Theme?",
|
|
Options: themes.Names(),
|
|
Default: chosenTheme,
|
|
},
|
|
&chosenTheme,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
viper.Set(keyTheme, chosenTheme)
|
|
theme, err := currentTheme()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Step 4: persist final config (URL, token, theme, insecure) if we
|
|
// collected anything new. Doing this after the TLS toggle and theme
|
|
// pick means the saved file actually matches the run the user is
|
|
// about to do.
|
|
if shouldOfferSave {
|
|
save := true
|
|
if err := survey.AskOne(
|
|
&survey.Confirm{Message: fmt.Sprintf("Save these to %s?", configPath()), Default: true},
|
|
&save,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
if save {
|
|
if err := writeConfigFile(); err != nil {
|
|
warnf("could not save config: %v", err)
|
|
} else {
|
|
printf("config saved to %s", configPath())
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 5: entity multi-select.
|
|
type entity struct {
|
|
Label string
|
|
Key string
|
|
Slow bool
|
|
Default bool
|
|
}
|
|
// Note: `vulns` is intentionally NOT offered here. It needs direct
|
|
// MySQL access (a DSN) that the wizard doesn't collect — run
|
|
// `dibble vulns --dsn ... --macos N --ubuntu N --windows N` directly.
|
|
entities := []entity{
|
|
{"users", "users", false, true},
|
|
{"teams", "teams", false, true},
|
|
{"enroll-secrets", "enroll-secrets", false, true},
|
|
{"labels", "labels", false, true},
|
|
{"policies", "policies", false, true},
|
|
{"reports (queries)", "reports", false, false},
|
|
{"scripts", "scripts", false, false},
|
|
{"profiles (Apple + Windows MDM)", "profiles", false, false},
|
|
{"software (titles, no upload)", "software", false, false},
|
|
{"activities (direct MySQL, non-idempotent)", "activities", true, false},
|
|
{"idp (direct MySQL, links users + hosts)", "idp", false, false},
|
|
{"cas (direct MySQL, non-idempotent)", "cas", false, false},
|
|
}
|
|
labels := make([]string, len(entities))
|
|
defaults := []string{}
|
|
for i, e := range entities {
|
|
labels[i] = e.Label
|
|
if e.Slow {
|
|
labels[i] += " ⚠"
|
|
}
|
|
if e.Default {
|
|
defaults = append(defaults, labels[i])
|
|
}
|
|
}
|
|
var picked []string
|
|
if err := survey.AskOne(
|
|
&survey.MultiSelect{Message: "What would you like to seed?", Options: labels, Default: defaults},
|
|
&picked,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
wanted := map[string]bool{}
|
|
for _, p := range picked {
|
|
for i, e := range entities {
|
|
if p == labels[i] {
|
|
wanted[e.Key] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 6: counts.
|
|
counts := defaultAllCounts()
|
|
// Pre-fill IDP defaults so the count prompt (and runWizardSelection
|
|
// downstream) sees sensible numbers when the user picked IDP. Default
|
|
// is 0 in defaultAllCounts because `dibble all` skips IDP unless
|
|
// explicitly requested.
|
|
if wanted["idp"] {
|
|
counts.IDPUserCount = 3
|
|
counts.IDPHostCount = 5
|
|
}
|
|
customise := false
|
|
if err := survey.AskOne(
|
|
&survey.Confirm{Message: "Use default counts?", Default: true},
|
|
&customise,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
customise = !customise // confirm says "use defaults yes" → don't customise
|
|
|
|
if customise {
|
|
counts = promptCounts(wanted, counts)
|
|
}
|
|
|
|
// Step 7: run.
|
|
return runWizardSelection(client, theme, wanted, counts)
|
|
}
|
|
|
|
// promptConfig prompts for any missing URL/token and updates viper. Returns
|
|
// whether each value was actually asked for (so we know whether to offer
|
|
// persisting).
|
|
func promptConfig() (bool, bool, error) {
|
|
urlAsked, tokenAsked := false, false
|
|
|
|
if viper.GetString(keyFleetURL) == "" {
|
|
var u string
|
|
err := survey.AskOne(
|
|
&survey.Input{Message: "Fleet URL?", Default: "http://localhost:8080"},
|
|
&u,
|
|
survey.WithValidator(func(in any) error {
|
|
s, _ := in.(string)
|
|
if s == "" {
|
|
return errors.New("required")
|
|
}
|
|
if _, err := url.Parse(s); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}),
|
|
)
|
|
if err != nil {
|
|
return false, false, err
|
|
}
|
|
viper.Set(keyFleetURL, u)
|
|
urlAsked = true
|
|
}
|
|
|
|
if viper.GetString(keyAPIToken) == "" {
|
|
var t string
|
|
err := survey.AskOne(
|
|
&survey.Password{Message: "Fleet API token?"},
|
|
&t,
|
|
survey.WithValidator(func(in any) error {
|
|
s, _ := in.(string)
|
|
if strings.TrimSpace(s) == "" {
|
|
return errors.New("required")
|
|
}
|
|
return nil
|
|
}),
|
|
)
|
|
if err != nil {
|
|
return false, false, err
|
|
}
|
|
viper.Set(keyAPIToken, t)
|
|
tokenAsked = true
|
|
}
|
|
return urlAsked, tokenAsked, nil
|
|
}
|
|
|
|
// writeConfigFile persists only the keys dibble manages. Existing fields in
|
|
// the file are preserved on best-effort: we read first, then merge.
|
|
func writeConfigFile() error {
|
|
path := configPath()
|
|
existing := map[string]any{}
|
|
if data, err := os.ReadFile(path); err == nil {
|
|
_ = yaml.Unmarshal(data, &existing)
|
|
}
|
|
existing[keyFleetURL] = viper.GetString(keyFleetURL)
|
|
existing[keyAPIToken] = viper.GetString(keyAPIToken)
|
|
existing[keyTheme] = viper.GetString(keyTheme)
|
|
// Always write the current insecure value (not just when true) so that
|
|
// toggling it off in a later run actually clears the persisted setting.
|
|
existing[keyInsecure] = viper.GetBool(keyInsecure)
|
|
data, err := yaml.Marshal(existing)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(path, data, 0o600)
|
|
}
|
|
|
|
func promptCounts(wanted map[string]bool, defaults allCounts) allCounts {
|
|
out := defaults
|
|
ask := func(label string, dst *int) {
|
|
var s string
|
|
_ = survey.AskOne(
|
|
&survey.Input{Message: label, Default: fmt.Sprintf("%d", *dst)},
|
|
&s,
|
|
)
|
|
var v int
|
|
_, _ = fmt.Sscanf(s, "%d", &v)
|
|
if v > 0 {
|
|
*dst = v
|
|
}
|
|
}
|
|
if wanted["users"] {
|
|
ask("users count", &out.Users)
|
|
}
|
|
if wanted["teams"] {
|
|
ask("teams count", &out.Teams)
|
|
}
|
|
if wanted["policies"] {
|
|
ask("policies count", &out.Policies)
|
|
}
|
|
if wanted["reports"] {
|
|
ask("reports count", &out.Reports)
|
|
}
|
|
if wanted["labels"] {
|
|
ask("labels count", &out.Labels)
|
|
}
|
|
if wanted["scripts"] {
|
|
ask("scripts count", &out.Scripts)
|
|
}
|
|
if wanted["profiles"] {
|
|
ask("profiles count", &out.Profiles)
|
|
}
|
|
if wanted["software"] {
|
|
ask("software count", &out.Software)
|
|
}
|
|
if wanted["cas"] {
|
|
ask("CAs count", &out.CAs)
|
|
}
|
|
if wanted["idp"] {
|
|
ask("idp user count", &out.IDPUserCount)
|
|
ask("idp host count (round-robin assignments)", &out.IDPHostCount)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// isTLSVerificationError reports whether the error came from an untrusted
|
|
// TLS certificate — the common pain point with self-signed dev Fleets.
|
|
func isTLSVerificationError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
var certErr *tls.CertificateVerificationError
|
|
if errors.As(err, &certErr) {
|
|
return true
|
|
}
|
|
msg := err.Error()
|
|
return strings.Contains(msg, "x509:") || strings.Contains(msg, "certificate signed by unknown authority")
|
|
}
|
|
|
|
func runWizardSelection(c *Client, theme themes.Theme, wanted map[string]bool, counts allCounts) error {
|
|
// Zero out entities the user didn't pick — runAll uses count > 0 as
|
|
// the implicit "do this" signal for everything except teams, which we
|
|
// gate explicitly to keep downstream seeders sensible.
|
|
if !wanted["users"] {
|
|
counts.Users = 0
|
|
}
|
|
if !wanted["teams"] {
|
|
counts.Teams = 0
|
|
}
|
|
if !wanted["policies"] {
|
|
counts.Policies = 0
|
|
}
|
|
if !wanted["reports"] {
|
|
counts.Reports = 0
|
|
}
|
|
if !wanted["labels"] {
|
|
counts.Labels = 0
|
|
}
|
|
if !wanted["scripts"] {
|
|
counts.Scripts = 0
|
|
}
|
|
if !wanted["profiles"] {
|
|
counts.Profiles = 0
|
|
}
|
|
if !wanted["software"] {
|
|
counts.Software = 0
|
|
}
|
|
if !wanted["cas"] {
|
|
counts.CAs = 0
|
|
}
|
|
if !wanted["activities"] {
|
|
counts.ActivityBatches = 0
|
|
} else if counts.ActivityBatches == 0 {
|
|
counts.ActivityBatches = 1
|
|
}
|
|
if !wanted["idp"] {
|
|
counts.IDPUserCount = 0
|
|
counts.IDPHostCount = 0
|
|
}
|
|
counts.EnrollSecrets = wanted["enroll-secrets"]
|
|
return runAll(c, theme, counts)
|
|
}
|